Skip to content

Batch Rebalance Data Sampler#47340

Open
delock wants to merge 12 commits into
huggingface:mainfrom
delock:gma/batch_rebalance_sampler
Open

Batch Rebalance Data Sampler#47340
delock wants to merge 12 commits into
huggingface:mainfrom
delock:gma/batch_rebalance_sampler

Conversation

@delock

@delock delock commented Jul 15, 2026

Copy link
Copy Markdown

CI

What does this PR do?

This PR introduce a training data sampler using a batch rebalance technique that reduces padding, balance cost between ranks and grad accumulation steps, which improves training efficiency.

Different from packed sequence data sampling, batch rebalance data sampling does not require varlen support in attention implementation. This makes it idea for researching or hardware that does not have varlen attention kernel implementation yet.

Different from group length sampler, this sampler does not require a global sort, so the sampler maintains true randomness in each effective batch.

How it works

The basic idea of batch rebalance data sampler is like this:

In distributed training, each rank processes a subset of samples called a micro-batch. When samples have different sequence lengths, the rank that happens to get longer sequences takes more time to compute, while other ranks finish early and wait. This idle waiting wastes GPU time.

Batch rebalance solves this by looking at all samples in a global batch, sorting them by length, and distributing them across ranks so that each rank gets a roughly equal computational cost — not an equal number of samples, but an equal workload. Short-sequence ranks get more samples; long-sequence ranks get fewer. The assignment is recomputed for every global batch using a lightweight greedy heuristic, with no communication between ranks.

For example, say you have 2 ranks, gradient accumulation of 1, and a global batch of 8 samples with token lengths [128, 256, 512, 768, 1024, 1536, 2048, 4096]. A random split assigns 4 samples per rank. If rank 0 happens to get [128, 512, 1024, 4096], every sample in that micro-batch is padded to 4096 — even the 128-token one — so rank 0 computes 4×4096 = 16,384 padded tokens. Meanwhile rank 1 gets [256, 768, 1536, 2048], padding to only 2048, computing 4×2048 = 8,192 tokens. Rank 0 takes twice as long, and rank 1 sits idle.

Batch rebalance sorts all 8 samples and assigns them in unequal counts: rank 0 gets just the 2 longest samples [4096, 2048] (2×4096 = 8,192 tokens), while rank 1 gets the remaining 6 shorter samples [1536, 1024, 768, 512, 256, 128] (6×1536 = 9,216 tokens). The workload is now nearly equal — no idle waiting.

Batch rebalance sampler uses a cost model to estimate the compute of each group of samples, then balances the total cost across ranks within the same synchronization step.

Specifically, for each global batch, the sampler sorts samples by length, partitions them into dp_size × grad_accum groups, and assigns groups to ranks so that ranks sharing the same gradient accumulation slot receive similar costs. Longer-sequence groups contain fewer samples; shorter-sequence groups contain more — but the estimated compute is even.

The default cost model is intercept + bs × max_len + alpha × bs × max_len², where bs is the number of samples in the group and max_len is the longest sequence. The quadratic term captures the super-linear scaling of attention. Users can plug in their own cost function via the cost_fn parameter to match their model's actual compute characteristics.

How to use

Set --train_sampling_strategy batch_rebalance in your training command. No other changes to your dataset or model code are needed.

python train.py
--train_sampling_strategy batch_rebalance
--per_device_train_batch_size 4
--gradient_accumulation_steps 2

Optional tuning parameters:

Parameter Default Description
--batch_rebalance_alpha 0.001 Quadratic cost weight. Increase if your model's attention cost dominates (e.g. long context). Set to 0.0 to use a linear-only cost model (bs × max_len).
--batch_rebalance_max_tokens 0 Safety cap on padded tokens (bs × max_len) per micro-batch to prevent OOM. 0 = no cap.

I confirm that this PR description and code is written with aid of LLM, and reviewed by a human being prior to PR submission.

Before submitting

[ ] (First-time contributors only): I confirm that this PR description and code is not written by an LLM or code agent

  • This PR fixes a typo or improves the docs (you can dismiss the other checks if that's the case).
  • Did you read the contributor guideline and the
    Pull Request checks?
  • Was this discussed/approved via a Github issue or the forum? Please add a link
    to it if that's the case.
  • Did you make sure to update the documentation with your changes according to the guidelines?
  • Did you write any new necessary tests?

Who can review?

Anyone in the community is free to review the PR once the tests have passed. Feel free to tag
members/contributors who may be interested in your PR.

delock added 10 commits July 10, 2026 19:57
New sampler that balances sequence length cost across DP ranks while
minimizing padding waste. Uses Poorman's Batch Rebalance algorithm with
configurable cost model.

Changes:
- trainer_pt_utils.py: Add BatchRebalanceSampler class
- training_args.py: Add 'batch_rebalance' to train_sampling_strategy
  choices, batch_rebalance_alpha and batch_rebalance_max_tokens params
- trainer.py: Add batch_rebalance branch in _get_train_sampler

Usage:
  TrainingArguments(train_sampling_strategy='batch_rebalance',
                    batch_rebalance_alpha=0.001)
Tests cover:
- Basic correctness (coverage, micro-batch count)
- No overlap between ranks within same global batch
- Effective batch size invariant
- max_tokens constraint enforcement
- Determinism (same seed = same result)
- Custom cost_fn override
PyTorch's Sampler base class doesn't accept data_source kwarg.
Drop the super().__init__() call entirely (consistent with other
HF samplers like LengthGroupedSampler).
…ens rebalance

- Remove micro_batch_size from BatchRebalanceSampler: it was stored but never
  read anywhere in the class, and its name was misleading since it did not
  reflect the actual minimum per-group size used by the algorithm.
- Fix a bug in _poormans_rebalance where counts[gi] -= 1 was applied
  unconditionally before searching for a donor group, causing samples to be
  silently dropped when max_tokens could not be satisfied and no group had
  spare capacity. Now the decrement only happens once a donor is found, and a
  warning is logged (with a fallback to exceed max_tokens rather than drop
  samples) when no donor is available.
- Fix docstring typo (Poorman's -> Poor Man's).
- Update/add tests: drop micro_batch_size from existing tests, add a
  regression test for the max_tokens data-loss fix, and rewrite the cost_fn
  test to directly assert on _cost() output instead of an unstable partition
  overlap comparison.
…wiring

BatchRebalanceSampler yields a full batch of sample indices per iteration
(BatchSampler semantics), but _get_dataloader() was passing it via
DataLoader's sampler= kwarg together with batch_size=, which expects the
sampler to yield one sample index at a time. This caused
'TypeError: list indices must be integers or slices, not list' as soon as
train_sampling_strategy="batch_rebalance" was used with the Trainer.

Also fixes a NameError (BatchEncoding was referenced in _get_train_sampler
but never imported), which was the first failure hit before reaching the
DataLoader wiring bug.

Together these two bugs meant the batch_rebalance strategy could never
actually run end-to-end through Trainer.train(); verified with a CPU
multi-process (torchrun --nproc_per_node=4) benchmark on Qwen2.5-0.5B with a
synthetic variable-length dataset after the fix.
When n < K * min_per_group, there are not enough samples to fill all groups.
Previously this was silently patched by trimming counts (and a dead 'total < n'
branch that could never trigger). Now it raises a clear error telling the user
to reduce effective_batch_size or increase dataset size.
- Replace can_increase(cts, gi) with can_transfer(cts, from_idx, to_idx)
  which simulates the actual -1/+1 transfer to compute the correct
  start index and max_len for the token limit check. The previous
  implementation used stale positions that didn't account for shifts
  caused by the donor group shrinking.
- When the lowest-cost group cannot accept a transfer due to max_tokens,
  iterate through remaining candidates by ascending cost instead of
  falling through to the nail-house branch or breaking entirely.
@github-actions

Copy link
Copy Markdown
Contributor

CI recap

Dashboard: View test results in Grafana
Latest run: 29406918422:2
Result: failure | Jobs: 15 | Tests: 171,078 | Failures: 0 | Duration: 13h 10m

Each iteration of the rebalance loop moves exactly one sample between groups,
so worst-case convergence is O(n). The fixed `K * 30` cap could therefore
truncate before convergence for large `effective_batch_size` with a small
`dp_size * grad_accum` (K), leaving the partition at a non-optimal cost spread.

Use `max(K * 30, effective_batch_size)` as the budget: the `effective_batch_size`
term covers the move-dominated regime (large batch, small K), while `K * 30`
remains a floor for the detection-dominated regime (tiny batch, n ~= K) where
convergence is reached via `spread == 0` / `seen` rather than the move budget.
`best_counts` is still tracked, so hitting the cap only degrades the result
gracefully (no samples are dropped).

Adds a regression test (large n=246, small K=2, heavily skewed lengths) that
asserts full sample coverage across all ranks and convergence to the
brute-force-optimal contiguous K=2 cost spread.
@delock

delock commented Jul 16, 2026

Copy link
Copy Markdown
Author

GPU DDP benchmark

Ran the sampler end-to-end through the Trainer on a real multi-GPU setup to validate the distributed path.

Setup

  • Hardware: 2× NVIDIA RTX 5090 (real DDP, world_size=2)
  • Model: Qwen/Qwen2.5-0.5B-Instruct (bf16, gradient checkpointing on so all three strategies fit at the chosen batch size)
  • Data: 1600 synthetic sequences, lengths ~ lognormvariate(mu=5.0, sigma=1.0) clamped to [16, 1024]
  • Config: per_device_train_batch_size=8, gradient_accumulation_steps=2effective batch size = 32, 50 optimizer steps
  • Methodology: the benchmark sizes the dataset to exactly max_steps × eff_bs, so every strategy processes the identical set of samples (375,880 real tokens) once — an apples-to-apples comparison. It also verifies per-epoch coverage (compute_loss samples seen, all-reduced) and reports real-tokens/s (= real tokens / end-to-end wall time, padding excluded), not the config-based train_samples_per_second.

Results

sampler real tok/s speedup vs random step time median / mean / p90 / max (s)
random (distributed) 15,873 1.00× 0.437 / 0.440 / 0.540 / 0.665
group_by_length 24,485 1.54× 0.232 / 0.270 / 0.395 / 0.640
batch_rebalance 25,085 1.58× 0.256 / 0.269 / 0.287 / 0.613

Step 5 sample distribution (step 5 = global_batch=32; 2 ranks × 2 grad-accum slots = 4 micro-batches; per-rank/slot bs and per-element lengths shown descending; each micro-batch is padded to its max):

random                         group_by_length                batch_rebalance
r0/s0 bs=8                     r0/s0 bs=8                     r0/s0 bs=2
 [495,354,343,257,187,121,      [244,242,242,241,239,239,      [806,753]
        92, 91]                        238,237]               r1/s0 bs=8
r1/s0 bs=8                     r1/s0 bs=8                      [249,246,243,215,203,
 [409,383,363,275,272, 76,      [235,234,234,232,232,226,            197,186,143]
        49, 41]                        225,225]               r0/s1 bs=3
r1/s1 bs=8                     r0/s1 bs=8                      [537,495,346]
 [735,613,388,257,205,184,      [223,222,215,213,210,208,     r1/s1 bs=19
       173,134]                        208,207]                [103,90,82,79,72,67,65,
r0/s1 bs=8                     r1/s1 bs=8                            60,57,57,54,54,50,
 [278,179,173,171,118, 80,      [206,205,201,200,196,195,            40,35,34,31,28,16]
        65, 34]                        194,193]
pad target (max) varies wildly  pad target tight per batch     long groups shrink (bs 2-3),
(91 -> 735); ranks imbalanced   but slot0 ~240 vs slot1 ~200   short groups grow (bs 8,19);
                                                               cost balanced across ranks/slots

Reproduce: benchmark script is at https://gist.github.com/delock/516efc09d9a4217e209729e91b9294de (bench_sampler.py). Run one strategy per invocation:

torchrun --nproc_per_node=2 bench_sampler.py --strategy batch_rebalance --gradient_checkpointing

@delock

delock commented Jul 16, 2026

Copy link
Copy Markdown
Author

group_by_length vs batch_rebalance: peak memory and max-batch throughput

A closer look at the trade-off between the two samplers. Same setup as the comment above, but no gradient checkpointing — so the memory limit each sampler hits is its own.

Setup: 2× NVIDIA RTX 5090 (32 GB), Qwen/Qwen2.5-0.5B-Instruct (bf16), real DDP (world_size=2), gradient_accumulation_steps=2, lengths ~ lognormvariate(mu=5.0, sigma=1.0) clamped to [16, 1024]. Peak VRAM = max across ranks of torch.cuda.max_memory_allocated. Reproduce: https://gist.github.com/delock/516efc09d9a4217e209729e91b9294de (bench_sampler.py).

1) Peak VRAM vs per_device_train_batch_size

per_device_bs group_by_length batch_rebalance
4 17.7 GB 12.3 GB
6 24.5 GB 15.4 GB
8 31.3 GB 20.4 GB
10 OOM 21.7 GB
12 OOM 24.5 GB
14 OOM 27.1 GB
16 OOM ~30 GB (borderline)
18 OOM OOM
  • group_by_length OOMs at per_device_bs ≥ 10 → largest reliable batch = 8 (peak ~31 GB, right at the edge).
  • batch_rebalance fits reliably up to 14 (peak ~28–30 GB); 16 is borderline (fits on short runs, OOMs over a full epoch); ≥18 OOMs.

batch_rebalance tolerates roughly 2× the per-device batch before OOM (8 → 14 reliable, 16 borderline; effective batch 8×2×2 = 32 vs 14×2×2 = 56), and at every batch size its peak is ~30–40% lower.

Why: group_by_length globally sorts by length, so the longest sequences land in a single micro-batch (per_device × 1024), and that one batch sets peak VRAM (the LM-head logits + activations dominate). batch_rebalance balances cost within each global batch, so the long-sequence group shrinks to just 2–3 samples → padded-tokens-per-micro-batch is bounded → no single batch spikes.

2) Throughput when each runs at its own max batch

sampler max per_device_bs real tok/s peak VRAM
group_by_length 8 30,174 32.3 GB
batch_rebalance 14 37,957 29.6 GB

batch_rebalance delivers ~1.26× higher real-tokens/s (37,957 vs 30,174) — and does so at lower peak VRAM (29.6 vs 32.3 GB).

The gain is not from per-step efficiency (at equal per_device_bs the two are close). It comes from batch_rebalance being able to use a larger batch on the same GPU → better GPU occupancy → more real tokens/s. group_by_length is memory-capped at per_device_bs=8 by its long-batch spike; batch_rebalance converts the extra memory headroom into throughput.

Net: compared to group_by_length, batch_rebalance has lower peak VRAM at every batch size, tolerates ~2× the batch before OOM, and when each is run at its max batch it's ~1.26× faster in real tokens/s.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution 🤗!

CI Security Gate — automatic approval blocked

This PR was not automatically approved for CI because the security gate failed.

Possible reasons:

  • The PR touches 50 or more files — only PRs with fewer than 50 changed files are automatically approved
  • A changed file is outside the allowed directories (src/, tests/, docs/, utils/), has a disallowed extension (only .py, .txt, .md permitted outside tests/ and docs/), or is not .md/.yml inside docs/
  • A new high-severity security issue was detected in the changed Python files (Bandit check)

See the workflow run for the exact violations.

A maintainer can review and manually approve CI if a finding is a false positive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant